Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
cee30c3
chore: scaffold :core:model-availability module
lokhor Jun 1, 2026
84187aa
feat(core): add KernelModel.isDeprecated field
lokhor Jun 1, 2026
bc9fc2c
feat(core): DownloadSource param, cancelDownload isRequired guard, SP…
lokhor Jun 1, 2026
0246929
feat(availability): ModelAvailabilityState, DownloadStateMapper, Gate…
lokhor Jun 1, 2026
c0d60f1
feat(availability): GatedModelStatus DataStore repository
lokhor Jun 1, 2026
da78082
feat(ui): extract CollapsibleSectionHeader to :core:ui
lokhor Jun 1, 2026
3f2f8d4
feat(availability): StateBadge and ModelCard composables
lokhor Jun 1, 2026
a6d9d91
feat(settings): ModelManagementViewModel - availabilitySummary, downl…
lokhor Jun 1, 2026
5d66a39
feat(settings): ModelManagementScreen - remove HF row, use ModelCard
lokhor Jun 2, 2026
6b4f664
feat(settings): SettingsScreen - model availability row, remove HF row
lokhor Jun 2, 2026
170c3d0
chore: add model-availability dep to chat, update AGENTS.md, add skil…
lokhor Jun 2, 2026
5a2268e
docs: add subagent output recovery pattern to AGENTS.md
lokhor Jun 2, 2026
73480ca
docs: strengthen subagent diff recovery template with explicit steps
lokhor Jun 2, 2026
2382bed
feat(settings): ModelSettingsScreen state badges, availability in Vie…
lokhor Jun 2, 2026
f803289
feat(chat): replace ModelProgressRow with ModelCardCompact in onboarding
lokhor Jun 2, 2026
f7fd952
feat(settings): replace VoiceScreen inline download composables with …
lokhor Jun 2, 2026
6c1bf11
fix: Oracle review — wire Cancel/Update actions, fix deprecated filte…
lokhor Jun 2, 2026
03caed6
fix(#1057): fast-open VAD gate — eliminate 240ms onset blind window
lokhor Jun 2, 2026
6e0f6f2
Merge remote-tracking branch 'origin/main' into feature/model-availab…
lokhor Jun 3, 2026
b6e1abe
fix(#1067): Address oracle code review findings
lokhor Jun 3, 2026
adb897f
fix(#1067): Oracle review round 2 — duplicate header, FQN imports, nu…
lokhor Jun 3, 2026
7ae9316
fix(#1067): Add LinearProgressIndicator to ModelCard and ModelCardCom…
lokhor Jun 3, 2026
7038668
fix(#1067): Oracle code review round 3 — downloadSources passthrough,…
lokhor Jun 3, 2026
87dcdbd
fix(#1067): Allow delete of non-selected downloaded models
lokhor Jun 4, 2026
25cdd39
fix(#1067): Review fixes — protect infrastructure models, remove dead…
lokhor Jun 4, 2026
e3574e9
fix(#1067): Address reviewer issues — gated collection, delete guard,…
lokhor Jun 4, 2026
06a27aa
fix(#1067): Move download guard after isRequired check in deleteModel
lokhor Jun 4, 2026
39ba62a
fix(#1067): Simplify deleteModel — remove isConversationSwap guard
lokhor Jun 4, 2026
99a7210
fix(#1067): Address code review findings
lokhor Jun 4, 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
27 changes: 25 additions & 2 deletions .omp/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Batch fallback: NPU → GPU (Adreno 740) → CPU. E-4B and E-2B support thinking
| `:core:voice` | STT, TTS, voice mode, push-to-talk |
| `:core:memory` | sqlite-vec JNI, EmbeddingGemma, RAG pipeline |
| `:core:wasm` | Chicory Wasm host, bridge functions, resource limits |
| `:core:model-availability` | ModelAvailabilityState, StateBadge, ModelCard, GatedModelStatusRepo |
| `:core:ui` | Shared Compose components, Material 3 theme |
| `:core:skills` | SkillInterface, SkillRegistry, JSON schema gen |
| `:feature:chat` | Chat screen, conversation list, ChatViewModel |
Expand Down Expand Up @@ -69,6 +70,29 @@ Batch fallback: NPU → GPU (Adreno 740) → CPU. E-4B and E-2B support thinking

**Workflow:** Analyse → dispatch (android-developer / llm-engineer) → parallel test-writer + spec-writer → PR with `Closes #N` → parallel code-reviewer + CI → push fixes → owner tests via ADB → owner merges.

### Subagent code changes — recovery pattern

Task agents run in **ephemeral, isolated worktrees** that are cleaned up on completion.
Their file writes never reach your worktree. To extract their changes, use one of:

**Option A — diff output (preferred):** Add this to the end of every code-changing assignment:
```
LAST STEP — output your changes as a patch:
1. Run `git diff` (do NOT omit this step).
2. Copy the ENTIRE diff output into your final message verbatim,
wrapped in a ```diff code block.
Do NOT summarise your changes — I need the raw diff to `git apply`.
```
Then apply in your worktree: pipe the diff block into `git apply`.

**Option B — raw file content:** Instruct the agent to `cat` each modified file.
The artifact output will contain the full content; copy it with `write`.

**Option C — GitHub push:** For larger changes, tell the agent to `git push` its branch,
then `git fetch` + `git merge` from your worktree.

**Never** assume a `task` agent's file modifications are visible in your worktree.

## Branch isolation

**Do not modify the main checkout directly.** Every session that touches code must use a dedicated worktree:
Expand Down Expand Up @@ -164,9 +188,8 @@ Write to memory (`memory://root/skills/<name>/SKILL.md`) after discovering:
- Build/debug quirks (tool flags, adb incantations, test setup)
- Architectural invariants that caused a bug (e.g. "gemma4InitMutex required")
- Tool invocation patterns that save tokens (rtk, context-mode)

Consult memory via `memory://root` before starting work in an unfamiliar module.
Existing entries: model_loading_order, test_patterns, branch_isolation, rtk_token_saver, adreno_buffer_workaround, github_api_pagination, meal_planner_state, documentation_sync.
Existing entries: model_loading_order, test_patterns, branch_isolation, rtk_token_saver, adreno_buffer_workaround, github_api_pagination, meal_planner_state, documentation_sync, model_availability_state.

## On-demand reference docs

Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/com/kernel/ai/navigation/KernelNavHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,9 @@ fun KernelNavHost(
onNavigateToSettings = {
navController.navigate(ROUTE_SETTINGS)
},
onNavigateToModelManagement = {
navController.navigate(ROUTE_MODEL_MANAGEMENT)
},
)
}

Expand All @@ -499,6 +502,9 @@ fun KernelNavHost(
onNavigateToSettings = {
navController.navigate(ROUTE_SETTINGS)
},
onNavigateToModelManagement = {
navController.navigate(ROUTE_MODEL_MANAGEMENT)
},
)
}

Expand Down Expand Up @@ -599,6 +605,9 @@ fun KernelNavHost(
composable(ROUTE_VOICE) {
VoiceScreen(
onBack = { navController.popBackStack() },
onNavigateToModelManagement = {
navController.navigate(ROUTE_MODEL_MANAGEMENT)
},
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.kernel.ai.core.inference.download

/**
* Source of a download request — used to distinguish auto-queued downloads from
* user-initiated ones.
*
* - [AUTO_QUEUED]: Started by the system on startup (required models, tier-preferred models,
* and co-dependent files like SentencePiece). These cannot be cancelled via the UI.
* - [USER_INITIATED]: Started by explicit user action. Can be cancelled.
*/
enum class DownloadSource {
AUTO_QUEUED,
USER_INITIATED,
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ enum class KernelModel(
* Defaults to `true` so existing entries are unaffected.
*/
val showInModelManagement: Boolean = true,
/**
* If `true`, this model has been superseded and is hidden from the Model Management
* screen and the preferred-model picker. The existing download is not deleted — the
* user must manually delete it through the storage settings.
* Defaults to `false` so existing entries are unaffected.
*/
val isDeprecated: Boolean = false,
) {
GEMMA_4_E2B(
displayName = "Gemma 4 E-2B",
Expand Down Expand Up @@ -88,6 +95,7 @@ enum class KernelModel(
preferredForTier = null,
isGated = true,
licenceUrl = "https://huggingface.co/litert-community/embeddinggemma-300m",
isDeprecated = true,
),

EMBEDDING_GEMMA_SP_MODEL(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton

import kotlinx.coroutines.flow.update

private const val TAG = "ModelDownloadManager"

/**
Expand Down Expand Up @@ -70,6 +72,14 @@ class ModelDownloadManager @Inject constructor(

val downloadStates: StateFlow<Map<KernelModel, DownloadState>> = _downloadStates.asStateFlow()

/**
* Tracks the [DownloadSource] for each model. Populated when [startDownload] is called.
* Used by the UI layer to determine whether cancel is allowed.
*/
private val _downloadSources: MutableStateFlow<Map<KernelModel, DownloadSource>> =
MutableStateFlow(emptyMap())
val downloadSources: StateFlow<Map<KernelModel, DownloadSource>> = _downloadSources.asStateFlow()

val deviceTier: HardwareTier get() = hardwareProfileDetector.profile.tier

init {
Expand All @@ -87,7 +97,7 @@ class ModelDownloadManager @Inject constructor(
}
.forEach { model ->
Log.i(TAG, "Auto-queuing required model: ${model.displayName}")
startDownload(model)
startDownload(model, source = DownloadSource.AUTO_QUEUED)
}
// Auto-queue tier-specific optional models (e.g. E-4B on FLAGSHIP)
// NOTE: tier is already declared above
Expand All @@ -98,7 +108,7 @@ class ModelDownloadManager @Inject constructor(
}
.forEach { model ->
Log.i(TAG, "Auto-queuing ${model.displayName} for tier ${tier.name}")
startDownload(model)
startDownload(model, source = DownloadSource.AUTO_QUEUED)
}
// Auto-trigger gated required models when user signs in
scope.launch {
Expand All @@ -108,7 +118,7 @@ class ModelDownloadManager @Inject constructor(
KernelModel.entries
.filter { m -> m.isGated && m.isRequired }
.filter { m -> _downloadStates.value[m] is DownloadState.NotDownloaded }
.forEach { m -> startDownload(m) }
.forEach { m -> startDownload(m, source = DownloadSource.AUTO_QUEUED) }
}
}
}
Expand All @@ -127,13 +137,16 @@ class ModelDownloadManager @Inject constructor(
* - Otherwise → [ExistingWorkPolicy.REPLACE] to unstick any stale ENQUEUED job that
* Samsung's battery manager prevented from dispatching, and to restart FAILED jobs.
*/
fun startDownload(model: KernelModel, force: Boolean = false) {
fun startDownload(model: KernelModel, force: Boolean = false, source: DownloadSource = DownloadSource.USER_INITIATED) {
if (model.isBundled) return // bundled assets are always available; nothing to download
if (!force && model.isDownloaded(context)) {
updateState(model, DownloadState.Downloaded(model.localFile(context).absolutePath))
return
}


// Track the download source for UI layer
_downloadSources.update { it.toMutableMap().apply { put(model, source) } }
Log.i(TAG, "Enqueuing download for ${model.displayName}")
// updateState moved inside coroutine — don't reset progress to 0 if KEEP is chosen

Expand Down Expand Up @@ -193,7 +206,16 @@ class ModelDownloadManager @Inject constructor(

/** Cancel an in-progress download. The partial `.tmp` file is preserved for resumption. */
fun cancelDownload(model: KernelModel) {
// Only user-initiated downloads can be cancelled — auto-queued models are needed
// for the app to function. Check the stored source rather than model.isRequired
// because some required models may be user-initiated (e.g. E2B on FLAGSHIP).
val source = _downloadSources.value[model] ?: DownloadSource.USER_INITIATED
if (source == DownloadSource.AUTO_QUEUED) {
Log.w(TAG, "Refusing to cancel auto-queued download: ${model.displayName}")
return
}
workManager.cancelUniqueWork(model.workerTag)
_downloadSources.update { it.toMutableMap().apply { remove(model) } }
updateState(model, DownloadState.NotDownloaded)
Log.i(TAG, "Cancelled download for ${model.displayName}")
}
Expand All @@ -205,16 +227,16 @@ class ModelDownloadManager @Inject constructor(
return if (model.isDownloaded(context)) model.localFile(context).absolutePath else null
}

/**
* Re-checks the filesystem for [model] and updates [downloadStates] accordingly.
* Call this after manually deleting a model file so the UI reflects [DownloadState.NotDownloaded].
*/
fun refreshState(model: KernelModel) {
val newState = if (model.isDownloaded(context)) {
DownloadState.Downloaded(model.localFile(context).absolutePath)
} else {
DownloadState.NotDownloaded
}
// Clear stale source tracking since the model is no longer actively downloading
if (newState !is DownloadState.Downloading) {
_downloadSources.update { it.toMutableMap().apply { remove(model) } }
}
updateState(model, newState)
Log.i(TAG, "Refreshed state for ${model.displayName}: $newState")
}
Expand Down Expand Up @@ -267,7 +289,7 @@ class ModelDownloadManager @Inject constructor(
// -------------------------------------------------------------------------

private fun updateState(model: KernelModel, state: DownloadState) {
_downloadStates.value = _downloadStates.value.toMutableMap().apply { put(model, state) }
_downloadStates.update { it.toMutableMap().apply { put(model, state) } }
}

// Issue 3 fix: guard against launching duplicate observeWorkInfo coroutines
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.kernel.ai.core.inference.download

import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

class KernelModelTest {

@Test
fun `isDeprecated defaults to false for all models`() {
KernelModel.entries.forEach { model ->
// Only SM8550 is explicitly deprecated; all others default to false
if (model == KernelModel.EMBEDDING_GEMMA_300M_SM8550) {
assertTrue(model.isDeprecated, "Expected ${model.name} to be deprecated")
} else {
assertFalse(model.isDeprecated, "Expected ${model.name} isDeprecated to be false")
}
}
}

@Test
fun `deprecated model is excluded from preferredForTier matches`() {
// SM8550 is deprecated — it should not match any tier preference logic
assertTrue(KernelModel.EMBEDDING_GEMMA_300M_SM8550.isDeprecated)
}
Comment on lines +21 to +25
}
66 changes: 66 additions & 0 deletions core/model-availability/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}

android {
namespace = "com.kernel.ai.core.model.availability"
compileSdk = libs.versions.compileSdk.get().toInt()

defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

buildFeatures {
compose = true
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

kotlinOptions {
jvmTarget = "17"
}

testOptions {
unitTests.all { it.useJUnitPlatform() }
}
}

dependencies {
implementation(project(":core:ui"))
implementation(project(":core:inference"))

// Compose
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons)
implementation(libs.compose.foundation)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose)

ksp(libs.hilt.compiler)

// Hilt
implementation(libs.hilt.android)

// DataStore
implementation(libs.datastore.preferences)

debugImplementation(libs.compose.ui.tooling)

compileOnly(libs.compose.ui.test.manifest)

testImplementation(libs.junit.jupiter)
testImplementation(libs.mockk)
testImplementation(libs.coroutines.test)
testImplementation(libs.compose.ui.test.junit4)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.kernel.ai.core.model.availability

import com.kernel.ai.core.inference.download.DownloadSource
import com.kernel.ai.core.inference.download.DownloadState
import com.kernel.ai.core.inference.download.KernelModel

/**
* Summary counts of models in each [ModelAvailabilityState].
*/
data class AvailabilitySummary(
val total: Int,
val ready: Int = 0,
val preparing: Int = 0,
val actionRequired: Int = 0,
val unavailable: Int = 0,
) {
val displaySummary: String get() {
return "$ready of $total models available"
}
}

/**
* Computes an [AvailabilitySummary] from a list of models and their download states.
*/
fun computeAvailabilitySummary(
models: List<KernelModel>,
downloadStates: Map<KernelModel, DownloadState>,
hfAuth: Boolean,
downloadSources: Map<KernelModel, DownloadSource> = emptyMap(),
gatedStatuses: Map<KernelModel, GatedModelStatus> = emptyMap(),
): AvailabilitySummary {
var ready = 0
var preparing = 0
var actionRequired = 0
var unavailable = 0

for (model in models) {
val state = downloadStates[model] ?: DownloadState.NotDownloaded
val source = downloadSources[model] ?: DownloadSource.USER_INITIATED
val gatedStatus = gatedStatuses[model] ?: GatedModelStatus.NONE
val availability = state.toAvailability(
model = model,
hfAuth = hfAuth,
source = source,
gated = gatedStatus,
)
when (availability) {
is ModelAvailabilityState.Ready -> ready++
is ModelAvailabilityState.Preparing -> preparing++
is ModelAvailabilityState.ActionRequired -> actionRequired++
is ModelAvailabilityState.Unavailable -> unavailable++
ModelAvailabilityState.NotDisplayed -> {} // NotDisplayed = no badge shown
}
}

return AvailabilitySummary(
total = models.size,
ready = ready,
preparing = preparing,
actionRequired = actionRequired,
unavailable = unavailable,
)
}
Loading
Loading