feat(chat/activity): Self-improvement event badges for memory & skill updates (#744)
* feat(chat/activity): Self-improvement event badges for memory & skill updates (#737) * feat(chat): Handle background review.summary WS events for self-improvement notices --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
2cf41ead06
commit
1b596746af
12 files changed
+381
-1
No files matched your search
@@ -0,0 +1,42 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Data structures for the Hermes agent self-improvement / learning graph endpoint
|
||||
* GET /api/learning/graph
|
||||
*/
|
||||
@Serializable
|
||||
data class LearningGraphNode(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val kind: String, // "skill" | "memory"
|
||||
val timestamp: Long? = null,
|
||||
val category: String? = null,
|
||||
@SerialName("useCount") val useCount: Int = 0,
|
||||
val state: String? = null,
|
||||
@SerialName("createdBy") val createdBy: String? = null,
|
||||
val pinned: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LearningGraphEdge(
|
||||
val source: String,
|
||||
val target: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LearningGraphMemoryCard(
|
||||
val source: String,
|
||||
val timestamp: Long? = null,
|
||||
val title: String,
|
||||
val body: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LearningGraphResponse(
|
||||
val nodes: List<LearningGraphNode> = emptyList(),
|
||||
val edges: List<LearningGraphEdge> = emptyList(),
|
||||
val memory: List<LearningGraphMemoryCard> = emptyList(),
|
||||
)
|
||||
@@ -34,6 +34,7 @@ import com.m57.hermescontrol.data.model.HookResponse
|
||||
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||
import com.m57.hermescontrol.data.model.KanbanTask
|
||||
import com.m57.hermescontrol.data.model.LearningGraphResponse
|
||||
import com.m57.hermescontrol.data.model.LogResponse
|
||||
import com.m57.hermescontrol.data.model.ManagedDirectoryCreate
|
||||
import com.m57.hermescontrol.data.model.ManagedFileActionResponse
|
||||
@@ -667,6 +668,11 @@ interface HermesApiService {
|
||||
suspend fun getPortal(): Response<PortalResponse>
|
||||
|
||||
// ── Admin: Curator ────────────────────────────────────────────────
|
||||
@GET("api/learning/graph")
|
||||
suspend fun getLearningGraph(
|
||||
@Query("profile") profile: String? = null,
|
||||
): Response<LearningGraphResponse>
|
||||
|
||||
@GET("api/curator")
|
||||
suspend fun getCurator(): Response<CuratorResponse>
|
||||
|
||||
|
||||
@@ -149,6 +149,11 @@ object EventParser {
|
||||
WsEvent.BackgroundComplete(payload)
|
||||
}
|
||||
|
||||
"review.summary" -> {
|
||||
val text = (payload?.get("text") as? String)?.trim() ?: ""
|
||||
WsEvent.ReviewSummary(text, sessionId)
|
||||
}
|
||||
|
||||
"session.updated" -> {
|
||||
WsEvent.SessionUpdated(payload)
|
||||
}
|
||||
|
||||
@@ -123,6 +123,16 @@ sealed class WsEvent {
|
||||
val sessionId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
/**
|
||||
* Self-improvement background review summary event.
|
||||
* Emitted when background review patches a skill or saves a memory.
|
||||
* Payload: `{ text: "💾 Self-improvement review: ..." }`
|
||||
*/
|
||||
data class ReviewSummary(
|
||||
val text: String,
|
||||
val sessionId: String? = null,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Status ───────────────────────────────────────────────────────────
|
||||
|
||||
data class StatusUpdate(
|
||||
|
||||
@@ -471,12 +471,70 @@ private fun AssistantBubble(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelfImprovementReviewCard(
|
||||
content: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val cleanText =
|
||||
content
|
||||
.removePrefix("💾")
|
||||
.replace(Regex("^\\s*Self-improvement review:\\s*", RegexOption.IGNORE_CASE), "")
|
||||
.trim()
|
||||
val isSkill =
|
||||
cleanText.contains("skill", ignoreCase = true) ||
|
||||
cleanText.contains("SKILL.md", ignoreCase = true)
|
||||
val icon = if (isSkill) "⚡" else "🧠"
|
||||
val title =
|
||||
if (isSkill) {
|
||||
"Self-Improvement Review • Skill Patched"
|
||||
} else {
|
||||
"Self-Improvement Review • Memory Updated"
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||
.testTag("self_improvement_review_card"),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = icon, fontSize = 16.sp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = cleanText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SystemBubble(
|
||||
message: ChatMessage,
|
||||
onRespondApproval: (String) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (message.content.contains("Self-improvement review:", ignoreCase = true)) {
|
||||
SelfImprovementReviewCard(content = message.content, modifier = modifier)
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
@@ -941,7 +999,13 @@ fun parseToolOutput(
|
||||
val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString
|
||||
val usage = dataSource.get("usage")?.takeIf { !it.isJsonNull }?.asString
|
||||
|
||||
val summaryText = "💾 $action${target?.let { " ($it)" } ?: ""}"
|
||||
val summaryText =
|
||||
when (action.lowercase()) {
|
||||
"add" -> "🧠 Memory Saved${target?.let { " ($it)" } ?: ""}"
|
||||
"replace", "update" -> "🧠 Memory Updated${target?.let { " ($it)" } ?: ""}"
|
||||
"remove", "delete" -> "🧠 Memory Removed${target?.let { " ($it)" } ?: ""}"
|
||||
else -> "🧠 Memory $action${target?.let { " ($it)" } ?: ""}"
|
||||
}
|
||||
val mainOutput =
|
||||
when {
|
||||
errorMsg != null -> "❌ $errorMsg"
|
||||
@@ -972,6 +1036,48 @@ fun parseToolOutput(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Skill Manage-specific formatting (Self-Improvement) ──
|
||||
if (resolvedToolName == "skill_manage") {
|
||||
val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: ""
|
||||
val name = argsObj?.get("name")?.takeIf { !it.isJsonNull }?.asString
|
||||
val category = argsObj?.get("category")?.takeIf { !it.isJsonNull }?.asString
|
||||
val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString
|
||||
val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true
|
||||
val msg = dataSource.get("message")?.takeIf { !it.isJsonNull }?.asString
|
||||
|
||||
val summaryText =
|
||||
when (action.lowercase()) {
|
||||
"create" -> "⚡ Skill Created${name?.let { ": $it" } ?: ""}"
|
||||
"patch" -> "⚡ Skill Patched${name?.let { ": $it" } ?: ""}"
|
||||
"edit" -> "⚡ Skill Edited${name?.let { ": $it" } ?: ""}"
|
||||
"delete" -> "⚡ Skill Archived${name?.let { ": $it" } ?: ""}"
|
||||
"write_file" -> "⚡ Skill File Written${name?.let { ": $it" } ?: ""}"
|
||||
"remove_file" -> "⚡ Skill File Removed${name?.let { ": $it" } ?: ""}"
|
||||
else ->
|
||||
"⚡ Skill ${action.replaceFirstChar {
|
||||
if (it.isLowerCase()) it.titlecase(java.util.Locale.getDefault()) else it.toString()
|
||||
}}${name?.let { ": $it" } ?: ""}"
|
||||
}
|
||||
val mainOutput =
|
||||
when {
|
||||
errorMsg != null -> "❌ $errorMsg"
|
||||
!success -> "❌ Skill operation failed"
|
||||
msg != null -> "✅ $msg"
|
||||
name != null -> "✅ Skill '$name' $action succeeded${category?.let { " [$it]" } ?: ""}"
|
||||
else -> "✅ Skill $action done"
|
||||
}
|
||||
val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble
|
||||
return ParsedToolData(
|
||||
toolName = resolvedToolName,
|
||||
args = args,
|
||||
result = dataSource.entrySet().associate { it.key to it.value.toString() },
|
||||
summaryText = summaryText,
|
||||
mainOutput = mainOutput,
|
||||
durationSec = duration,
|
||||
isRunning = isRunning,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Cronjob-specific formatting ──
|
||||
if (resolvedToolName == "cronjob") {
|
||||
val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: ""
|
||||
|
||||
@@ -40,6 +40,7 @@ object ChatWsEventReducer {
|
||||
is WsEvent.ToolProgress -> event.sessionId
|
||||
is WsEvent.ToolGenerating -> event.sessionId
|
||||
is WsEvent.SubagentEvent -> event.sessionId
|
||||
is WsEvent.ReviewSummary -> event.sessionId
|
||||
else -> null
|
||||
}
|
||||
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
|
||||
@@ -95,6 +96,8 @@ object ChatWsEventReducer {
|
||||
|
||||
is WsEvent.ClarifyRequest -> onClarifyRequest(state, streamingState, event)
|
||||
|
||||
is WsEvent.ReviewSummary -> onReviewSummary(state, streamingState, event)
|
||||
|
||||
is WsEvent.RpcError -> onRpcError(state, streamingState, event)
|
||||
|
||||
is WsEvent.GatewayError -> onGatewayError(state, streamingState, event)
|
||||
@@ -506,6 +509,25 @@ object ChatWsEventReducer {
|
||||
),
|
||||
)
|
||||
|
||||
// ── ReviewSummary (Self-improvement background review) ──────────────
|
||||
|
||||
private fun onReviewSummary(
|
||||
state: ChatUiState,
|
||||
streamingState: StreamingState,
|
||||
event: WsEvent.ReviewSummary,
|
||||
): ReducerResult {
|
||||
if (event.text.isBlank()) return ReducerResult(state = state, streamingState = streamingState)
|
||||
val systemMessage =
|
||||
ChatMessage(
|
||||
role = MessageRole.SYSTEM,
|
||||
content = event.text,
|
||||
)
|
||||
return ReducerResult(
|
||||
state = state.copy(messages = state.messages + systemMessage),
|
||||
streamingState = streamingState,
|
||||
)
|
||||
}
|
||||
|
||||
// ── RpcError ──────────────────────────────────────────────────────
|
||||
|
||||
private fun onRpcError(
|
||||
|
||||
@@ -49,6 +49,7 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -69,6 +70,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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.R
|
||||
@@ -288,6 +290,9 @@ fun SystemScreen(
|
||||
// ── 3. Curator ─────────────────────────────────────────
|
||||
curatorSection(state, spacing, statusColors, viewModel)
|
||||
|
||||
// ── 3.5 Self-Improvement & Learning ───────────────────
|
||||
selfImprovementSection(state, spacing, statusColors)
|
||||
|
||||
// ── 4. Gateway ─────────────────────────────────────────
|
||||
gatewaySection(state, spacing, statusColors, viewModel)
|
||||
|
||||
@@ -707,6 +712,122 @@ 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(
|
||||
state: SystemUiState,
|
||||
spacing: com.m57.hermescontrol.theme.Spacing,
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.m57.hermescontrol.data.model.CuratorResponse
|
||||
import com.m57.hermescontrol.data.model.DebugShareResponse
|
||||
import com.m57.hermescontrol.data.model.DoctorResponse
|
||||
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.StatusResponse
|
||||
@@ -40,6 +41,7 @@ data class SystemUiState(
|
||||
val portal: PortalResponse? = null,
|
||||
val curator: CuratorResponse? = null,
|
||||
val memory: MemoryResponse? = null,
|
||||
val learningGraph: LearningGraphResponse? = null,
|
||||
val credentials: List<CredentialPoolProvider> = emptyList(),
|
||||
val checkpoints: CheckpointsResponse? = null,
|
||||
val hooks: HookResponse? = null,
|
||||
@@ -100,6 +102,7 @@ class SystemViewModel :
|
||||
val portalDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getPortal() } }
|
||||
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 checkpointsDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCheckpoints() } }
|
||||
val hooksDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getHooks() } }
|
||||
@@ -112,6 +115,7 @@ class SystemViewModel :
|
||||
val portalResult = portalDeferred.await()
|
||||
val curatorResult = curatorDeferred.await()
|
||||
val memoryResult = memoryDeferred.await()
|
||||
val learningResult = learningDeferred.await()
|
||||
val credResult = credDeferred.await()
|
||||
val checkpointsResult = checkpointsDeferred.await()
|
||||
val hooksResult = hooksDeferred.await()
|
||||
@@ -126,6 +130,7 @@ class SystemViewModel :
|
||||
portal = (portalResult as? NetworkResult.Success)?.data,
|
||||
curator = (curatorResult as? NetworkResult.Success)?.data,
|
||||
memory = (memoryResult as? NetworkResult.Success)?.data,
|
||||
learningGraph = (learningResult as? NetworkResult.Success)?.data,
|
||||
credentials =
|
||||
((credResult as? NetworkResult.Success)?.data)?.providers ?: emptyList(),
|
||||
checkpoints = (checkpointsResult as? NetworkResult.Success)?.data,
|
||||
|
||||
Reference in new issue
Block a user