feat(memory): dedicated Memory screen (moved out of System tab) (#835)

* feat(memory): dedicated Memory screen — providers, builtin files & reset (moved from System tab)

* feat(memory): move self-improvement & learning activity section from System tab to Memory screen

* fix(memory): remove top padding, single-line reset buttons, providers spacing

* fix(memory): tighten provider card padding

* fix(memory): distinct AutoStories icon for Memory tab

* chore: ignore local debug apk

* feat(ui): fresh icon set for drawer screens

* feat(ui): custom Neurology icon for Memory tab

* fix(ui): theme-driven fill for NeurologyIcon (checkColorLiterals)

The custom Neurology vector used SolidColor(Color.Black), tripping the
hardcoded-color guard (issue #622). The fill only contributes alpha —
Icon()'s tint replaces the RGB at draw time, mirroring Material's own
icon set — so the literal belongs in theme/ like every other hardcoded
color: new IconDefaultFill token, scoped there by the guard's design.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-07 04:18:50 +03:00
1 parent fd5d851405
commit 7d00d7b81b
13 files changed
+764 -313

No files matched your search

+1
View File
@@ -28,3 +28,4 @@ ktlint
# direnv # direnv
.direnv/ .direnv/
hermes-chat-redesign.html hermes-chat-redesign.html
app-debug-835.apk
@@ -39,6 +39,8 @@ import kotlinx.serialization.Serializable
@Serializable data object McpServersScreen : NavKey @Serializable data object McpServersScreen : NavKey
@Serializable data object MemoryScreen : NavKey
@Serializable data object WebhooksScreen : NavKey @Serializable data object WebhooksScreen : NavKey
@Serializable data object ModelScreen : NavKey @Serializable data object ModelScreen : NavKey
@@ -3,7 +3,6 @@ package com.m57.hermescontrol
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.automirrored.filled.ListAlt
import androidx.compose.material.icons.filled.AccountBalanceWallet import androidx.compose.material.icons.filled.AccountBalanceWallet
import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.BarChart import androidx.compose.material.icons.filled.BarChart
@@ -11,23 +10,27 @@ import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Devices import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.EmojiEvents import androidx.compose.material.icons.filled.EmojiEvents
import androidx.compose.material.icons.filled.Extension import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.HistoryEdu import androidx.compose.material.icons.filled.HistoryEdu
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Inventory2
import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Memory import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.Power
import androidx.compose.material.icons.filled.Psychology import androidx.compose.material.icons.filled.Psychology
import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.Sensors
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.ViewKanban
import androidx.compose.material.icons.filled.Webhook import androidx.compose.material.icons.filled.Webhook
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import com.m57.hermescontrol.ui.common.NeurologyIcon
import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent
import com.m57.hermescontrol.ui.analytics.AnalyticsScreen as AnalyticsScreenContent import com.m57.hermescontrol.ui.analytics.AnalyticsScreen as AnalyticsScreenContent
import com.m57.hermescontrol.ui.billing.BillingScreen as BillingScreenContent import com.m57.hermescontrol.ui.billing.BillingScreen as BillingScreenContent
@@ -41,6 +44,7 @@ import com.m57.hermescontrol.ui.kanban.KanbanScreen as KanbanScreenContent
import com.m57.hermescontrol.ui.keys.KeysScreen as KeysScreenContent import com.m57.hermescontrol.ui.keys.KeysScreen as KeysScreenContent
import com.m57.hermescontrol.ui.logs.LogsScreen as LogsScreenContent import com.m57.hermescontrol.ui.logs.LogsScreen as LogsScreenContent
import com.m57.hermescontrol.ui.mcp.McpServersScreen as McpServersScreenContent import com.m57.hermescontrol.ui.mcp.McpServersScreen as McpServersScreenContent
import com.m57.hermescontrol.ui.memory.MemoryScreen as MemoryScreenContent
import com.m57.hermescontrol.ui.model.ModelScreen as ModelScreenContent import com.m57.hermescontrol.ui.model.ModelScreen as ModelScreenContent
import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent
import com.m57.hermescontrol.ui.plugins.PluginsScreen as PluginsScreenContent import com.m57.hermescontrol.ui.plugins.PluginsScreen as PluginsScreenContent
@@ -107,13 +111,13 @@ object ScreenRegistry {
ScreenDefinition( ScreenDefinition(
KanbanScreen, KanbanScreen,
R.string.screen_kanban, R.string.screen_kanban,
Icons.Filled.Dashboard, Icons.Filled.ViewKanban,
DrawerSection.AUTOMATE, DrawerSection.AUTOMATE,
) { sessionId, openDrawer -> KanbanScreenContent(onOpenDrawer = openDrawer) }, ) { sessionId, openDrawer -> KanbanScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition( ScreenDefinition(
SkillsScreen, SkillsScreen,
R.string.screen_skills, R.string.screen_skills,
Icons.Filled.Extension, Icons.Filled.Inventory2,
DrawerSection.CONFIGURE, DrawerSection.CONFIGURE,
) { sessionId, openDrawer -> SkillsScreenContent(onOpenDrawer = openDrawer) }, ) { sessionId, openDrawer -> SkillsScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition( ScreenDefinition(
@@ -125,7 +129,7 @@ object ScreenRegistry {
ScreenDefinition( ScreenDefinition(
PluginsScreen, PluginsScreen,
R.string.screen_plugins, R.string.screen_plugins,
Icons.Filled.Memory, Icons.Filled.Extension,
DrawerSection.CONFIGURE, DrawerSection.CONFIGURE,
) { sessionId, openDrawer -> PluginsScreenContent(onOpenDrawer = openDrawer) }, ) { sessionId, openDrawer -> PluginsScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition( ScreenDefinition(
@@ -137,9 +141,15 @@ object ScreenRegistry {
ScreenDefinition( ScreenDefinition(
McpServersScreen, McpServersScreen,
R.string.screen_mcp_servers, R.string.screen_mcp_servers,
Icons.Filled.Dashboard, Icons.Filled.Power,
DrawerSection.CONFIGURE, DrawerSection.CONFIGURE,
) { sessionId, openDrawer -> McpServersScreenContent(onOpenDrawer = openDrawer) }, ) { sessionId, openDrawer -> McpServersScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition(
MemoryScreen,
R.string.screen_memory,
NeurologyIcon,
DrawerSection.CONFIGURE,
) { sessionId, openDrawer -> MemoryScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition( ScreenDefinition(
ModelScreen, ModelScreen,
R.string.screen_models, R.string.screen_models,
@@ -161,7 +171,7 @@ object ScreenRegistry {
ScreenDefinition( ScreenDefinition(
ChannelsScreen, ChannelsScreen,
R.string.screen_channels, R.string.screen_channels,
Icons.AutoMirrored.Filled.ListAlt, Icons.Filled.Sensors,
DrawerSection.CONFIGURE, DrawerSection.CONFIGURE,
) { sessionId, openDrawer -> ChannelsScreenContent(onOpenDrawer = openDrawer) }, ) { sessionId, openDrawer -> ChannelsScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition( ScreenDefinition(
@@ -179,7 +189,7 @@ object ScreenRegistry {
ScreenDefinition( ScreenDefinition(
SystemScreen, SystemScreen,
R.string.screen_system, R.string.screen_system,
Icons.Filled.Info, Icons.Filled.Dns,
DrawerSection.INSPECT, DrawerSection.INSPECT,
) { sessionId, openDrawer -> SystemScreenContent(onOpenDrawer = openDrawer) }, ) { sessionId, openDrawer -> SystemScreenContent(onOpenDrawer = openDrawer) },
ScreenDefinition( ScreenDefinition(
@@ -106,3 +106,10 @@ val CodeDiffDeleteBg = Color(0x26FF5C5C)
val CodeDiffDeleteText = Color(0xFFFF5C5C) val CodeDiffDeleteText = Color(0xFFFF5C5C)
val CodeDiffHunkBg = Color(0x264DA8FF) val CodeDiffHunkBg = Color(0x264DA8FF)
val CodeDiffHunkText = Color(0xFF4DA8FF) val CodeDiffHunkText = Color(0xFF4DA8FF)
// ── Custom vector icons ─────────────────────────────────────────────
// Default fill for hand-declared ImageVector icons (e.g. NeurologyIcon).
// Mirrors Material's own icon set: vectors are drawn opaque black and
// Icon()'s tint (LocalContentColor) replaces the RGB at draw time — only
// the alpha channel survives, so this must stay opaque.
val IconDefaultFill = Color.Black
@@ -0,0 +1,57 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.addPathNodes
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.theme.IconDefaultFill
/**
* Material Symbols "neurology" (outlined, 24dp) — the glyph ships on
* fonts.google.com but is NOT in the material-icons-extended snapshot the
* app depends on, so it is declared here as a custom vector from Google's
* original path data (viewBox 0 -960 960 960, translated into compose space).
*/
val NeurologyIcon: ImageVector by lazy {
ImageVector.Builder(
name = "Neurology",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 960f,
viewportHeight = 960f,
).apply {
addGroup(
name = "translate",
rotate = 0f,
pivotX = 0f,
pivotY = 0f,
scaleX = 1f,
scaleY = 1f,
translationX = 0f,
translationY = 960f,
clipPathData = emptyList(),
)
addPath(
pathData = addPathNodes(PATH),
pathFillType = PathFillType.NonZero,
name = "NeurologyPath",
fill = SolidColor(IconDefaultFill),
fillAlpha = 1f,
stroke = null,
strokeAlpha = 1f,
strokeLineWidth = 1f,
strokeLineCap = StrokeCap.Butt,
strokeLineJoin = StrokeJoin.Miter,
strokeLineMiter = 4f,
trimPathStart = 0f,
trimPathEnd = 1f,
trimPathOffset = 0f,
)
}.build()
}
private const val PATH =
"M390-120q-51 0-88-35.5T260-241q-60-8-100-53t-40-106q0-21 5.5-41.5T142-480q-11-18-16.5-38t-5.5-42q0-61 40-105.5t99-52.5q3-51 41-86.5t90-35.5q26 0 48.5 10t41.5 27q18-17 41-27t49-10q52 0 89.5 35t40.5 86q59 8 99.5 53T840-560q0 22-5.5 42T818-480q11 18 16.5 38.5T840-400q0 62-40.5 106.5T699-241q-5 50-41.5 85.5T570-120q-25 0-48.5-9.5T480-156q-19 17-42 26.5t-48 9.5Zm130-590v460q0 21 14.5 35.5T570-200q20 0 34.5-16t15.5-36q-21-8-38.5-21.5T550-306q-10-14-7.5-30t16.5-26q14-10 30-7.5t26 16.5q11 16 28 24.5t37 8.5q33 0 56.5-23.5T760-400q0-5-.5-10t-2.5-10q-17 10-36.5 15t-40.5 5q-17 0-28.5-11.5T640-440q0-17 11.5-28.5T680-480q33 0 56.5-23.5T760-560q0-33-23.5-56T680-640q-11 18-28.5 31.5T613-587q-16 6-31-1t-20-23q-5-16 1.5-31t22.5-20q15-5 24.5-18t9.5-30q0-21-14.5-35.5T570-760q-21 0-35.5 14.5T520-710Zm-80 460v-460q0-21-14.5-35.5T390-760q-21 0-35.5 14.5T340-710q0 16 9 29.5t24 18.5q16 5 23 20t2 31q-6 16-21 23t-31 1q-21-8-38.5-21.5T279-640q-32 1-55.5 24.5T200-560q0 33 23.5 56.5T280-480q17 0 28.5 11.5T320-440q0 17-11.5 28.5T280-400q-21 0-40.5-5T203-420q-2 5-2.5 10t-.5 10q0 33 23.5 56.5T280-320q20 0 37-8.5t28-24.5q10-14 26-16.5t30 7.5q14 10 16.5 26t-7.5 30q-14 19-32 33t-39 22q1 20 16 35.5t35 15.5q21 0 35.5-14.5T440-250Zm40-230Z"
@@ -0,0 +1,434 @@
@file:OptIn(
androidx.compose.material3.ExperimentalMaterial3Api::class,
androidx.compose.foundation.layout.ExperimentalLayoutApi::class,
)
package com.m57.hermescontrol.ui.memory
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.MemoryProviderDetailKey
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.LearningGraphResponse
import com.m57.hermescontrol.data.model.MemoryProviderStatusRow
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SectionHeader
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.StatCard
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.ui.plugins.memoryProviderStatusLabel
import com.m57.hermescontrol.ui.plugins.memoryProviderStatusType
/**
* Memory management home — owns the memory surface that used to live in the
* System tab (active provider, builtin files + reset) plus the provider list
* that drills into per-provider config/setup (issue #783).
*/
@Composable
fun MemoryScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: MemoryViewModel = viewModel { MemoryViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
var resetTarget by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
viewModel.load()
}
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
resetTarget?.let { target ->
AlertDialog(
onDismissRequest = { resetTarget = null },
title = { Text(stringResource(R.string.memory_reset_confirm_title)) },
text = { Text(stringResource(R.string.memory_reset_confirm_desc, target)) },
confirmButton = {
TextButton(
onClick = {
resetTarget = null
viewModel.resetMemory(target)
},
) {
Text(stringResource(R.string.action_ok))
}
},
dismissButton = {
TextButton(onClick = { resetTarget = null }) {
Text(stringResource(R.string.system_confirm_cancel))
}
},
)
}
HermesScaffold(
title = { Text(stringResource(R.string.screen_memory)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading,
onRefresh = { viewModel.load() },
modifier = modifier,
) { paddingValues ->
when {
state.isLoading && state.memory == null -> {
SkeletonListState(modifier = Modifier)
}
state.errorMessage != null && state.memory == null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.load() },
modifier = Modifier,
)
}
state.memory == null -> {
EmptyState(
title = stringResource(R.string.memory_empty_title),
subtitle = stringResource(R.string.memory_empty_desc),
onAction = { viewModel.load() },
actionLabel = stringResource(R.string.content_desc_refresh),
modifier = Modifier,
)
}
else -> {
MemoryContent(
memory = state.memory!!,
learningGraph = state.learningGraph,
resetting = state.resetting,
onResetRequest = { resetTarget = it },
modifier = Modifier,
)
}
}
}
}
@Composable
private fun MemoryContent(
memory: com.m57.hermescontrol.data.model.MemoryResponse,
learningGraph: LearningGraphResponse?,
resetting: String?,
onResetRequest: (String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = listContentPadding,
verticalArrangement = listItemSpacing,
) {
// Active provider + builtin files
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
if (memory.active.isNullOrBlank()) {
Text(
text = stringResource(R.string.memory_builtin),
style = MaterialTheme.typography.titleMedium,
)
} else {
Text(
text = stringResource(R.string.memory_active, memory.active),
style = MaterialTheme.typography.titleMedium,
)
}
memory.builtin_files?.let { files ->
Spacer(modifier = Modifier.height(8.dp))
Text(
text =
stringResource(
R.string.memory_builtin_sizes,
files.memory?.let { formatBytes(it) } ?: "?",
files.user?.let { formatBytes(it) } ?: "?",
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(12.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilledTonalButton(
onClick = { onResetRequest("memory") },
enabled = resetting == null,
) {
Text(
text = stringResource(R.string.memory_reset_memory),
maxLines = 1,
softWrap = false,
)
}
FilledTonalButton(
onClick = { onResetRequest("user") },
enabled = resetting == null,
) {
Text(
text = stringResource(R.string.memory_reset_user),
maxLines = 1,
softWrap = false,
)
}
FilledTonalButton(
onClick = { onResetRequest("all") },
enabled = resetting == null,
) {
Text(
text = stringResource(R.string.memory_reset_all),
maxLines = 1,
softWrap = false,
)
}
}
}
}
}
// Providers
if (memory.providers.isNotEmpty()) {
item {
Text(
text = stringResource(R.string.memory_providers_heading).uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
modifier = Modifier.padding(start = 16.dp, end = 16.dp),
)
}
items(memory.providers, key = { it.name }) { provider ->
MemoryProviderRow(
provider = provider,
isActive = provider.name == memory.active,
onClick = {
NavigationController.navigateTo(
MemoryProviderDetailKey(
name = provider.name,
label = provider.name,
),
)
},
)
}
}
// Self-Improvement & learning activity (moved from System tab)
item {
SelfImprovementSection(graph = learningGraph)
}
}
}
@Composable
private fun SelfImprovementSection(graph: LearningGraphResponse?) {
SectionHeader(title = stringResource(R.string.memory_sec_self_improvement))
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.memory_self_improvement_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
if (graph == null || graph.nodes.isEmpty()) {
Text(
text = stringResource(R.string.memory_self_improvement_no_activity),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
} else {
val skillNodes = graph.nodes.filter { it.kind == "skill" }
val memoryNodes = graph.nodes.filter { it.kind == "memory" }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
StatCard(
label = "Learned Skills",
value = "${skillNodes.size}",
modifier = Modifier.weight(1f),
)
StatCard(
label = "Memories & Facts",
value = "${memoryNodes.size}",
modifier = Modifier.weight(1f),
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Recent Agent Self-Improvement Events",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(4.dp))
val sortedNodes =
graph.nodes
.filter { it.timestamp != null || it.kind == "skill" }
.sortedByDescending { it.timestamp ?: 0L }
.take(8)
sortedNodes.forEach { node ->
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val icon = if (node.kind == "skill") "⚡" else "🧠"
Text(text = icon, fontSize = 16.sp)
Spacer(modifier = Modifier.width(4.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = node.label,
style =
MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val cat = node.category ?: node.kind
val creator = node.createdBy?.let { " • by $it" } ?: ""
Text(
text = "[$cat]$creator",
style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
node.useCount.takeIf { it > 0 }?.let { uses ->
StatusBadge(
text = "$uses uses",
status = StatusBadgeType.INFO,
)
}
}
}
}
}
}
}
}
@Composable
private fun MemoryProviderRow(
provider: MemoryProviderStatusRow,
isActive: Boolean,
onClick: () -> Unit,
) {
Card(
modifier =
Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = provider.name,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.weight(1f),
)
if (isActive) {
StatusBadge(
text = stringResource(R.string.memory_provider_active),
status = StatusBadgeType.SUCCESS,
)
}
StatusBadge(
text = memoryProviderStatusLabel(provider.status),
status = memoryProviderStatusType(provider.status),
)
}
if (provider.description.isNotBlank()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = provider.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
private fun formatBytes(bytes: Long): String =
when {
bytes >= 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
bytes >= 1024 -> "%.1f KB".format(bytes / 1024.0)
else -> "$bytes B"
}
@@ -0,0 +1,94 @@
package com.m57.hermescontrol.ui.memory
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.LearningGraphResponse
import com.m57.hermescontrol.data.model.MemoryResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class MemoryUiState(
val isLoading: Boolean = false,
val memory: MemoryResponse? = null,
val learningGraph: LearningGraphResponse? = null,
val errorMessage: String? = null,
val toastMessage: String? = null,
val resetting: String? = null,
)
/**
* Memory management home (moved out of the System tab) — active provider,
* builtin memory files with reset, and the provider list that drills into
* per-provider config/setup (issue #783). Deliberately no explicit IO hop:
* Retrofit suspend calls already run off the caller thread, keeping tests
* free of the static Dispatchers mock that bleeds across test classes.
*/
class MemoryViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(MemoryUiState())
val uiState: StateFlow<MemoryUiState> = _uiState.asStateFlow()
fun load() {
if (_uiState.value.isLoading) return
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.getMemory() }
when (result) {
is NetworkResult.Success ->
_uiState.update { it.copy(isLoading = false, memory = result.data) }
is NetworkResult.Failure ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = result.error.message,
)
}
}
loadLearningGraph()
}
}
private fun loadLearningGraph() {
viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.getLearningGraph() }
if (result is NetworkResult.Success) {
_uiState.update { it.copy(learningGraph = result.data) }
}
}
}
fun resetMemory(target: String) {
if (_uiState.value.resetting != null) return
_uiState.update { it.copy(resetting = target) }
viewModelScope.launch {
val result = safeApiCall { ApiClient.hermesApi.resetMemory(mapOf("target" to target)) }
when (result) {
is NetworkResult.Success ->
_uiState.update {
it.copy(
resetting = null,
toastMessage = "Memory ($target) reset successfully",
)
}
is NetworkResult.Failure ->
_uiState.update {
it.copy(
resetting = null,
toastMessage = "Failed to reset memory: ${result.error.message}",
)
}
}
load()
}
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
}
@@ -455,7 +455,7 @@ private fun SetupStepRow(step: MemoryProviderSetupResult) {
} }
} }
private fun memoryProviderStatusType(status: String): StatusBadgeType = fun memoryProviderStatusType(status: String): StatusBadgeType =
when (status) { when (status) {
"ready" -> StatusBadgeType.SUCCESS "ready" -> StatusBadgeType.SUCCESS
"needs_config" -> StatusBadgeType.WARNING "needs_config" -> StatusBadgeType.WARNING
@@ -21,7 +21,6 @@ import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.Devices import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.HealthAndSafety import androidx.compose.material.icons.filled.HealthAndSafety
@@ -49,7 +48,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -70,7 +68,6 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
@@ -152,29 +149,6 @@ fun SystemScreen(
) )
} }
// Memory reset confirmation
var resetTarget by remember { mutableStateOf<String?>(null) }
if (resetTarget != null) {
AlertDialog(
onDismissRequest = { resetTarget = null },
title = { Text(stringResource(R.string.system_memory_reset_confirm_title)) },
text = { Text(stringResource(R.string.system_memory_reset_confirm_desc)) },
confirmButton = {
TextButton(onClick = {
resetTarget?.let { viewModel.resetMemory(it) }
resetTarget = null
}) {
Text(stringResource(R.string.action_ok))
}
},
dismissButton = {
TextButton(onClick = { resetTarget = null }) {
Text(stringResource(R.string.system_confirm_cancel))
}
},
)
}
// Credential remove confirmation // Credential remove confirmation
var credToRemove by remember { mutableStateOf<Pair<String, Int>?>(null) } var credToRemove by remember { mutableStateOf<Pair<String, Int>?>(null) }
if (credToRemove != null) { if (credToRemove != null) {
@@ -290,15 +264,9 @@ fun SystemScreen(
// ── 3. Curator ───────────────────────────────────────── // ── 3. Curator ─────────────────────────────────────────
curatorSection(state, spacing, statusColors, viewModel) curatorSection(state, spacing, statusColors, viewModel)
// ── 3.5 Self-Improvement & Learning ───────────────────
selfImprovementSection(state, spacing, statusColors)
// ── 4. Gateway ───────────────────────────────────────── // ── 4. Gateway ─────────────────────────────────────────
gatewaySection(state, spacing, statusColors, viewModel) gatewaySection(state, spacing, statusColors, viewModel)
// ── 5. Memory ──────────────────────────────────────────
memorySection(state, spacing, resetTarget, { resetTarget = it }, viewModel)
// ── 6. Credentials ───────────────────────────────────── // ── 6. Credentials ─────────────────────────────────────
credentialsSection(state, spacing, viewModel, credToRemove, { credToRemove = it }) credentialsSection(state, spacing, viewModel, credToRemove, { credToRemove = it })
@@ -712,122 +680,6 @@ private fun LazyListScope.curatorSection(
} }
} }
private fun LazyListScope.selfImprovementSection(
state: SystemUiState,
spacing: com.m57.hermescontrol.theme.Spacing,
statusColors: com.m57.hermescontrol.theme.HermesStatusColors,
) {
val graph = state.learningGraph
item {
SectionHeader(title = stringResource(R.string.system_sec_self_improvement))
}
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(spacing.md)) {
Text(
text = stringResource(R.string.system_self_improvement_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(spacing.sm))
if (graph == null || graph.nodes.isEmpty()) {
Text(
text = stringResource(R.string.system_self_improvement_no_activity),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
} else {
val skillNodes = graph.nodes.filter { it.kind == "skill" }
val memoryNodes = graph.nodes.filter { it.kind == "memory" }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
StatCard(
label = "Learned Skills",
value = "${skillNodes.size}",
modifier = Modifier.weight(1f),
)
StatCard(
label = "Memories & Facts",
value = "${memoryNodes.size}",
modifier = Modifier.weight(1f),
)
}
Spacer(modifier = Modifier.height(spacing.md))
Text(
text = "Recent Agent Self-Improvement Events",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(spacing.xs))
val sortedNodes =
graph.nodes
.filter { it.timestamp != null || it.kind == "skill" }
.sortedByDescending { it.timestamp ?: 0L }
.take(8)
sortedNodes.forEach { node ->
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val icon = if (node.kind == "skill") "⚡" else "🧠"
Text(text = icon, fontSize = 16.sp)
Spacer(modifier = Modifier.width(spacing.xs))
Column(modifier = Modifier.weight(1f)) {
Text(
text = node.label,
style =
MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val cat = node.category ?: node.kind
val creator = node.createdBy?.let { " • by $it" } ?: ""
Text(
text = "[$cat]$creator",
style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
node.useCount.takeIf { it > 0 }?.let { uses ->
StatusBadge(
text = "$uses uses",
status = StatusBadgeType.INFO,
)
}
}
}
}
}
}
}
}
}
private fun LazyListScope.gatewaySection( private fun LazyListScope.gatewaySection(
state: SystemUiState, state: SystemUiState,
spacing: com.m57.hermescontrol.theme.Spacing, spacing: com.m57.hermescontrol.theme.Spacing,
@@ -980,100 +832,6 @@ private fun LazyListScope.gatewaySection(
} }
} }
private fun LazyListScope.memorySection(
state: SystemUiState,
spacing: com.m57.hermescontrol.theme.Spacing,
resetTarget: String?,
onResetRequest: (String) -> Unit,
viewModel: SystemViewModel,
) {
state.memory?.let { memory ->
item {
SectionHeader(title = stringResource(R.string.system_sec_memory))
}
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(spacing.md)) {
// Active provider
memory.active?.let { active ->
InfoRow(
label = stringResource(R.string.system_memory_external, active),
value = "",
)
} ?: InfoRow(
label = stringResource(R.string.system_memory_builtin),
value = "",
)
// Change in Plugins link
InfoRow(
label = stringResource(R.string.system_memory_change_plugins),
value = "",
)
// Builtin file sizes
memory.builtin_files?.let { files ->
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
InfoRow(
label =
stringResource(
R.string.system_memory_builtin_label,
files.memory?.let { formatBytes(it) } ?: "?",
files.user?.let { formatBytes(it) } ?: "?",
),
value = "",
)
// Reset buttons
Spacer(modifier = Modifier.height(spacing.sm))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
FilledTonalButton(
onClick = { onResetRequest("memory") },
modifier = Modifier.weight(1f),
contentPadding = actionButtonPadding,
) {
ActionButtonContent(
icon = Icons.Filled.Delete,
text = stringResource(R.string.system_memory_reset_memory),
)
}
FilledTonalButton(
onClick = { onResetRequest("user") },
modifier = Modifier.weight(1f),
contentPadding = actionButtonPadding,
) {
ActionButtonContent(
icon = Icons.Filled.Delete,
text = stringResource(R.string.system_memory_reset_user),
)
}
FilledTonalButton(
onClick = { onResetRequest("all") },
modifier = Modifier.weight(1f),
contentPadding = actionButtonPadding,
) {
ActionButtonContent(
icon = Icons.Filled.DeleteForever,
text = stringResource(R.string.system_memory_reset_all),
)
}
}
}
}
}
}
}
}
private fun LazyListScope.credentialsSection( private fun LazyListScope.credentialsSection(
state: SystemUiState, state: SystemUiState,
spacing: com.m57.hermescontrol.theme.Spacing, spacing: com.m57.hermescontrol.theme.Spacing,
@@ -12,8 +12,6 @@ import com.m57.hermescontrol.data.model.CuratorResponse
import com.m57.hermescontrol.data.model.DebugShareResponse import com.m57.hermescontrol.data.model.DebugShareResponse
import com.m57.hermescontrol.data.model.DoctorResponse import com.m57.hermescontrol.data.model.DoctorResponse
import com.m57.hermescontrol.data.model.HookResponse import com.m57.hermescontrol.data.model.HookResponse
import com.m57.hermescontrol.data.model.LearningGraphResponse
import com.m57.hermescontrol.data.model.MemoryResponse
import com.m57.hermescontrol.data.model.PortalResponse import com.m57.hermescontrol.data.model.PortalResponse
import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.model.SystemStatsResponse import com.m57.hermescontrol.data.model.SystemStatsResponse
@@ -40,8 +38,6 @@ data class SystemUiState(
val stats: SystemStatsResponse? = null, val stats: SystemStatsResponse? = null,
val portal: PortalResponse? = null, val portal: PortalResponse? = null,
val curator: CuratorResponse? = null, val curator: CuratorResponse? = null,
val memory: MemoryResponse? = null,
val learningGraph: LearningGraphResponse? = null,
val credentials: List<CredentialPoolProvider> = emptyList(), val credentials: List<CredentialPoolProvider> = emptyList(),
val checkpoints: CheckpointsResponse? = null, val checkpoints: CheckpointsResponse? = null,
val hooks: HookResponse? = null, val hooks: HookResponse? = null,
@@ -101,8 +97,7 @@ class SystemViewModel :
val statusDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getStatus() } } val statusDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getStatus() } }
val portalDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getPortal() } } val portalDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getPortal() } }
val curatorDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCurator() } } val curatorDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCurator() } }
val memoryDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getMemory() } }
val learningDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getLearningGraph() } }
val credDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCredentialPool() } } val credDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCredentialPool() } }
val checkpointsDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCheckpoints() } } val checkpointsDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCheckpoints() } }
val hooksDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getHooks() } } val hooksDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getHooks() } }
@@ -114,8 +109,7 @@ class SystemViewModel :
val statusResult = statusDeferred.await() val statusResult = statusDeferred.await()
val portalResult = portalDeferred.await() val portalResult = portalDeferred.await()
val curatorResult = curatorDeferred.await() val curatorResult = curatorDeferred.await()
val memoryResult = memoryDeferred.await()
val learningResult = learningDeferred.await()
val credResult = credDeferred.await() val credResult = credDeferred.await()
val checkpointsResult = checkpointsDeferred.await() val checkpointsResult = checkpointsDeferred.await()
val hooksResult = hooksDeferred.await() val hooksResult = hooksDeferred.await()
@@ -129,8 +123,6 @@ class SystemViewModel :
status = (statusResult as? NetworkResult.Success)?.data, status = (statusResult as? NetworkResult.Success)?.data,
portal = (portalResult as? NetworkResult.Success)?.data, portal = (portalResult as? NetworkResult.Success)?.data,
curator = (curatorResult as? NetworkResult.Success)?.data, curator = (curatorResult as? NetworkResult.Success)?.data,
memory = (memoryResult as? NetworkResult.Success)?.data,
learningGraph = (learningResult as? NetworkResult.Success)?.data,
credentials = credentials =
((credResult as? NetworkResult.Success)?.data)?.providers ?: emptyList(), ((credResult as? NetworkResult.Success)?.data)?.providers ?: emptyList(),
checkpoints = (checkpointsResult as? NetworkResult.Success)?.data, checkpoints = (checkpointsResult as? NetworkResult.Success)?.data,
@@ -148,7 +140,6 @@ class SystemViewModel :
"status" to statusResult, "status" to statusResult,
"portal" to portalResult, "portal" to portalResult,
"curator" to curatorResult, "curator" to curatorResult,
"memory" to memoryResult,
"credentials" to credResult, "credentials" to credResult,
"checkpoints" to checkpointsResult, "checkpoints" to checkpointsResult,
"hooks" to hooksResult, "hooks" to hooksResult,
@@ -193,15 +184,6 @@ class SystemViewModel :
} }
} }
fun loadMemory() {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getMemory() } }
if (result is NetworkResult.Success) {
_uiState.update { it.copy(memory = result.data) }
}
}
}
fun loadStatus() { fun loadStatus() {
viewModelScope.launch { viewModelScope.launch {
val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getStatus() } } val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getStatus() } }
@@ -318,27 +300,6 @@ class SystemViewModel :
) )
} }
// ── Memory actions ─────────────────────────────────────────────────
fun resetMemory(target: String) {
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.resetMemory(mapOf("target" to target)) }
}
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Memory ($target) reset successfully") }
loadMemory()
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to reset memory: ${result.error.message}") }
}
}
}
}
// ── Credential pool actions ──────────────────────────────────────── // ── Credential pool actions ────────────────────────────────────────
fun updateCredProvider(v: String) { fun updateCredProvider(v: String) {
+16 -13
View File
@@ -81,6 +81,7 @@
<string name="screen_plugins">Plugins</string> <string name="screen_plugins">Plugins</string>
<string name="screen_config">Config</string> <string name="screen_config">Config</string>
<string name="screen_mcp_servers">MCP Servers</string> <string name="screen_mcp_servers">MCP Servers</string>
<string name="screen_memory">Memory</string>
<string name="screen_models">Models</string> <string name="screen_models">Models</string>
<string name="screen_pairing">Pairing</string> <string name="screen_pairing">Pairing</string>
<string name="screen_keys">Keys</string> <string name="screen_keys">Keys</string>
@@ -366,9 +367,9 @@
<!-- Curator section --> <!-- Curator section -->
<string name="system_sec_curator">Skill curator</string> <string name="system_sec_curator">Skill curator</string>
<!-- Self-Improvement & Learning Activity --> <!-- Self-Improvement & Learning Activity -->
<string name="system_sec_self_improvement">Self-Improvement &amp; Learning Activity</string> <string name="memory_sec_self_improvement">Self-Improvement &amp; Learning Activity</string>
<string name="system_self_improvement_desc">Chronological log of agent skills, memories, and fact store updates learned over time.</string> <string name="memory_self_improvement_desc">Chronological log of agent skills, memories, and fact store updates learned over time.</string>
<string name="system_self_improvement_no_activity">No self-improvement events recorded yet.</string> <string name="memory_self_improvement_no_activity">No self-improvement events recorded yet.</string>
<string name="system_status_active">active</string> <string name="system_status_active">active</string>
<string name="system_status_paused">paused</string> <string name="system_status_paused">paused</string>
<string name="system_status_disabled">disabled</string> <string name="system_status_disabled">disabled</string>
@@ -388,16 +389,18 @@
<string name="system_gateway_stop">Stop</string> <string name="system_gateway_stop">Stop</string>
<!-- Memory section --> <!-- Memory section -->
<string name="system_sec_memory">Memory</string> <string name="memory_active">Active: %1$s</string>
<string name="system_memory_external">External provider: %1$s</string> <string name="memory_builtin">Built-in memory</string>
<string name="system_memory_builtin">built-in only</string> <string name="memory_builtin_sizes">MEMORY.md: %1$s · USER.md: %2$s</string>
<string name="system_memory_change_plugins">Change in Plugins</string> <string name="memory_reset_memory">Reset MEMORY.md</string>
<string name="system_memory_builtin_label">Built-in files — MEMORY.md: %1$s · USER.md: %2$s</string> <string name="memory_reset_user">Reset USER.md</string>
<string name="system_memory_reset_memory">Reset MEMORY.md</string> <string name="memory_reset_all">Reset all</string>
<string name="system_memory_reset_user">Reset USER.md</string> <string name="memory_reset_confirm_title">Reset memory</string>
<string name="system_memory_reset_all">Reset all</string> <string name="memory_reset_confirm_desc">This permanently erases the %1$s built-in memory file(s). This cannot be undone.</string>
<string name="system_memory_reset_confirm_title">Reset memory</string> <string name="memory_empty_title">No memory data</string>
<string name="system_memory_reset_confirm_desc">This permanently erases the selected built-in memory files. This cannot be undone.</string> <string name="memory_empty_desc">Could not load memory status.</string>
<string name="memory_providers_heading">Providers</string>
<string name="memory_provider_active">Active</string>
<!-- Credential pool section --> <!-- Credential pool section -->
<string name="system_sec_credentials">Credential pool</string> <string name="system_sec_credentials">Credential pool</string>
@@ -0,0 +1,131 @@
package com.m57.hermescontrol.ui.memory
import com.m57.hermescontrol.data.model.MemoryProviderStatusRow
import com.m57.hermescontrol.data.model.MemoryResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Response
/**
* Memory management home (moved out of the System tab). No
* mockkStatic(Dispatchers) — the ViewModel does no explicit IO hop, so
* setMain(StandardTestDispatcher) alone is deterministic.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MemoryViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApi: HermesApiService
private val memoryResponse =
MemoryResponse(
active = "web",
builtin_files =
com.m57.hermescontrol.data.model.BuiltinFileSizes(
memory = 1024,
user = 2048,
),
providers =
listOf(
MemoryProviderStatusRow(name = "web", status = "ready"),
MemoryProviderStatusRow(name = "tts", status = "needs_config"),
),
)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkObject(ApiClient)
mockApi = mockk(relaxed = true)
every { ApiClient.hermesApi } returns mockApi
coEvery { mockApi.getMemory() } returns Response.success(memoryResponse)
}
@After
fun tearDown() {
unmockkAll()
Dispatchers.resetMain()
}
@Test
fun `load fetches memory status with providers`() {
val vm = MemoryViewModel()
vm.load()
testDispatcher.scheduler.advanceUntilIdle()
val state = vm.uiState.value
assertEquals("web", state.memory?.active)
assertEquals(2, state.memory?.providers?.size)
assertEquals(1024L, state.memory?.builtin_files?.memory)
}
@Test
fun `load also fetches learning graph for self-improvement section`() {
val graph = com.m57.hermescontrol.data.model.LearningGraphResponse(nodes = emptyList())
coEvery { mockApi.getLearningGraph() } returns Response.success(graph)
val vm = MemoryViewModel()
vm.load()
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockApi.getLearningGraph() }
assertEquals(graph, vm.uiState.value.learningGraph)
}
@Test
fun `load failure surfaces error message`() {
coEvery { mockApi.getMemory() } returns
Response.error(500, "boom".toResponseBody())
val vm = MemoryViewModel()
vm.load()
testDispatcher.scheduler.advanceUntilIdle()
assertNull(vm.uiState.value.memory)
assertTrue(vm.uiState.value.errorMessage.orEmpty().isNotBlank())
}
@Test
fun `resetMemory posts target and reloads`() {
coEvery { mockApi.resetMemory(mapOf("target" to "all")) } returns
Response.success(emptyMap<String, String>())
val vm = MemoryViewModel()
vm.load()
testDispatcher.scheduler.advanceUntilIdle()
vm.resetMemory("all")
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockApi.resetMemory(mapOf("target" to "all")) }
assertEquals("Memory (all) reset successfully", vm.uiState.value.toastMessage)
// One load + one reload after reset.
coVerify(exactly = 2) { mockApi.getMemory() }
}
@Test
fun `resetMemory failure shows error toast`() {
coEvery { mockApi.resetMemory(mapOf("target" to "memory")) } returns
Response.error(500, "boom".toResponseBody())
val vm = MemoryViewModel()
vm.resetMemory("memory")
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.toastMessage.orEmpty().contains("Failed to reset memory"))
assertNull(vm.uiState.value.resetting)
}
}
@@ -48,11 +48,4 @@ class SystemViewModelTest {
viewModel.closeUpdateConfirm() viewModel.closeUpdateConfirm()
assertFalse(viewModel.uiState.value.updateConfirmOpen) assertFalse(viewModel.uiState.value.updateConfirmOpen)
} }
@Test
fun `initial uiState has null learningGraph`() {
val viewModel = SystemViewModel()
org.junit.Assert.assertNull(viewModel.uiState.value.learningGraph)
}
} }